fix(stats): count each session once and keep other projects out - #771
Conversation
|
Findings
No earlier findings were listed to mark as resolved. |
|
Thanks — all four addressed in Project root resolved independently (P1-1). You are right that One thing I want to flag rather than paper over: I probed whether a user-scope run can ever coincide with a detected project, and it cannot. Breakdowns aligned with the headline (P1-2). This one was a real defect and worse than I had realised. Through the built CLI, the same input produced Real-CLI e2e record (P1-3). Added: Token assertions (P2). Agreed — over-counted tokens were half the bug and the tests only checked sessions and turns. They now assert Each fix was reverted individually and the corresponding tests went red with the same numbers, so none of them is passing by accident — that check is what caught me the first time on the breakdown change, where my initial scenario could not distinguish the two implementations. |
|
Findings
Resolved
|
75d08cd to
7762bd1
Compare
|
Second round — three addressed, and one I want to push back on with evidence rather than accept. P1-2 (breakdowns): you were right, and I had made it worse. Filtering the breakdowns to unreported sessions did exactly what you said: a fully reported project vanished from Where I would frame it differently: the headline and the breakdown cannot be made to agree, and trying is what produced the bug. The headline adds this machine's unreported sessions to totals that already include other machines and sessions compaction has dropped — P2 (token-only growth): real, and it disappears with the revert. P1-3 (real-CLI record): already present, I think you read the earlier revision. P1-1 (user-scope unreachable): the diagnosis is correct, the consequence is not. I probed it rather than reasoning about it: If you think the breakdowns should be reconcilable with the headline, that is a design question I would rather settle explicitly than guess at — the only way I can see is to make the breakdown read from the reported totals rather than the local log, which would need per-repo data the team yaml does not carry today. |
|
Findings
Resolved
|
|
Third round — one of these was a real regression I introduced, and I want to be explicit about how it got past me.
This one is on me for a reason worth naming: I had a real-CLI run that appeared to show CHANGELOG out of sync: correct, fixed. It claimed the breakdowns were "consistent with the headline numbers", which the current implementation deliberately does not do. Rewritten to say the breakdowns read the same filtered log and stay inside the scope, and that they answer a different question from the headline.
Real-CLI record: it was stale, and that was the substantive part of your point. The record in the description came from the broken harness, so its numbers were wrong ( User-scope exclusion unreachable: diagnosis accepted, consequence still disputed. The probe result is in the PR body — |
|
Findings
Resolved
|
55e1285 to
9748974
Compare
|
Fourth round — two of these were real and are fixed in Flag descriptions and generated command reference out of sync: correct, fixed. I changed the The e2e record you are reading is the stale one. You quoted User-scope exclusion still a no-op: the mechanism is as you describe, and I have stopped defending the branch. You are right that when a user config resolves, detection from the same directory found nothing, so On the breakdown question generally — I am going to stop iterating on wording. Either (a) it stays as it is: same scope as the headline, different question, labelled, with the changelog saying they are not a split of the headline; or (b) the consistency claim goes away entirely and the breakdowns are simply what they always were, a view of the local log. I lean (a) because the scope fix is the actual bug and the labelling prevents the misreading, but I would rather have your call than keep re-deriving it. |
|
Findings
Resolved
|
|
Fifth round — three of these were real and are fixed in The e2e record did not exercise scope filtering: correct, and it was the important one. The fixture had only a user config and no out-of-scope session, so nothing in it could show "other projects stay out". Added a third run with a second project's session, driven from inside a project workspace so Reported totals merged wholesale, so a shared team repo leaks between projects: correct in mechanism, and worth being precise about. I have not fixed it here, deliberately: the team yaml carries no project attribution, so making reported totals project-aware means changing what the report path writes — a data-format change that affects every existing Copilot events dropped because User-scope exclusion unreachable: I have stopped arguing this one. The branch cannot fire as written, and I have said so in the PR body rather than defending it. The code now calls |
|
Findings
Resolved
|
f41596e to
7f07bc2
Compare
|
Sixth round — the undercounting one was real and is the most serious thing in this thread, because it was mine. Fixed in A snapshot the team file never received: confirmed, and it undercounts. My previous guard only checked that the reported totals were readable. The The fix trusts the snapshot only when the team total is non-empty. That is a sound licence rather than a heuristic: the report path writes the team file and advances the snapshot under the same lock, so a non-empty total is what makes the snapshot's claims about this team true. An empty or missing team total means nothing has been received, so nothing is subtracted. Reported totals carry no scope attribution: agreed, and deliberately not fixed here. With two project scopes on the same team repo and username, project A sees what project B already reported. That is real. It is also a data-format change — Copilot events dropped for lack of User-scope exclusion unreachable: not arguing it any more. The branch cannot fire as written; the changelog no longer claims the user scope excludes project events. The e2e record: it does contain the project-scope run. "Third run, the scope-filtering half" is in the description with a second project's session, One process note on me: I used |
|
Findings
Resolved
|
showStats added the whole machine's local events.jsonl metrics to the scope's already-reported totals, so every session a pull had reported — and that stays in the event log until compaction — was counted once by the team total and once again locally, and sessions whose cwd belonged to another project were added to this scope's totals too. Filter the event log the way `pull` reports it (a project scope keeps only sessions under its own root, the user scope excludes them) and add only what the scope has not reported yet, derived from the same per-session reported-* snapshots the report path advances, so the local figure agrees with the team's instead of exceeding it. The per-repo and by-hour breakdowns use the same filtered log, keeping them consistent with the headline numbers.
…oject root independently Three points from the review of the double-count fix: 1. The project root is resolved on its own with detectProjectConfig(), the same call `pull` makes, instead of being read off the resolved scope config. A user-scope config carries no projectRoot — that field is attached only when a PROJECT config is detected — so the old expression could never populate the user scope's exclusion list. 2. `--by-repo` and `--by-time` now describe the same local part the headline adds to the team totals. They consumed the whole retained event log, so with a reported session still on disk the headline said "1 session, 300 tokens" while the breakdown said "2 sessions, 1.8K tokens" — two numbers from one command that could not both be right. unreportedDashboardStats now also returns the set of sessions the scope still owes the team, and the breakdowns filter to it. 3. The regression tests assert token totals, not only sessions and conversation turns, since over-counted tokens were half the bug. Verified through the built CLI in an isolated HOME: the headline and the per-repo breakdown now report the same sessions, turns and tokens.
The previous round filtered the breakdowns down to unreported sessions so they would match the headline. That was the wrong trade: the headline adds this machine's unreported sessions to totals that already include other machines and sessions compaction has dropped, so it can never equal a per-repo or per-hour view of the local log. Filtering made the breakdowns show neither a total nor a delta — a fully reported project vanished from `--by-repo` entirely. Restore the full scoped log for the breakdowns and say in the comment what question each answers. What both must share is the SCOPE, and the breakdown tests now pin that: removing the scope filter turns the cross-project case red, which the earlier assertion missed because it read only the first matching row.
The delta path keyed off `config` alone, so a scope whose team stats could not be read at all — no stats file yet, an unreadable one, a reports worktree that is not there — still had its local snapshot subtracted. The snapshot records what this machine pushed, not what the team holds, and with the reported side null it hid sessions the member could see happening, down to "No usage data yet." Require `reported` as well, so the local aggregate is shown when there is no team total to reconcile against. The changelog entry no longer claims the breakdowns match the headline; they read the same scoped log and answer a different question, and the heading says so.
The `--by-repo` heading was changed to name the local event log, but the flag descriptions and the generated command reference still said "Break usage down per repository", and `--by-time` kept the old "(local time)" heading — so the two optional views disagreed with each other and with the changelog entry about them. Name the source in both flag descriptions, label the by-hour view the same way, and regenerate `commands.md` per AGENTS.md.
The end-to-end shape the review asked for was missing: a project scope with reported team totals, one reported session still in the event log, one new session in this project, and one session belonging to a different project. It now asserts 3 reported + 1 new = 4 sessions, 301 turns, and a breakdown holding only this project's rows. Dropping the scope filter turns four tests red at once (the new one reporting 5 instead of 4), so the filter is pinned rather than assumed.
The previous guard only checked that the reported totals were readable. The local snapshots are machine-global while the team file is per-scope, so a snapshot can name a session this team never got — an empty team file alongside a populated snapshot. Subtracting then undercounts, down to "No usage data yet." with sessions sitting in the event log. Trust the snapshot only when the team total is non-empty: the report path writes the team file and advances the snapshot under the same lock, so a non-empty total is what licenses the subtraction.
7f07bc2 to
069e79b
Compare
|
Findings
Resolved
|
The note inverted the guard's condition: the code trusts the reported snapshots only when the team total is NON-empty, while the wording said an empty total is what licenses trusting them. Say what the code does. It also claimed the user scope excludes project roots. A user config resolves only when detectProjectConfig() found no project, so there is no root to exclude and the log passes through unfiltered. State that instead of asserting an exclusion the branch cannot perform. No behaviour change; wording only.
|
Findings
Resolved
|
|
Seventh round — the branch was behind The P2 is correct and is fixed in User-scope exclusion unreachable — accepting the finding, and the changelog no longer claims otherwise. I am not defending the branch. A user config resolves only when The remaining three P1s are real, and I am deliberately not fixing them in this PR:
My position, unchanged: this PR fixes the local-event half of "another project's sessions" — the double count and the cross-project local rows — and leaves the reported-totals half, the snapshot keying, and the Copilot attribution as separate work, because each of them changes the report path's on-disk format or what |
* fix(stats): await the async dashboard scope filter (#795) #795 made filterEventsByScope async and changed its argument from a projectRoot/excludeProjectRoots filter to the scope config, while #771 still called it synchronously with the old filter. On main, tsc fails in stats.ts and every stats-scope test throws "events is not iterable"; `teamai stats` crashes once there are dashboard events. stats now awaits the filter and passes the scope config, the call pull makes, which is what #771 set out to do: show what the report sends. The cwd-based project-root resolution is gone with the old argument. The user-scope test followed pull's rule before #795 (keep events that carry no dataHome); it now follows the current one: the user scope never reports them, so it does not count them. * fix(stats): subtract the scope's own reported snapshots (#786) Since #795 each scope reports against its own reported-*.json under its data home, and the shared ~/.teamai/dashboard files are no longer written. stats still subtracted the shared files, so after the upgrade every session reported since counted twice in the headline. stats reads the snapshots through team-push's readers with the scope config, including the one-time seed from the shared file.
…ent#785, Tencent#786) `teamai stats` (Tencent#771) still called filterEventsByScope with the old { projectRoot, excludeProjectRoots } options, synchronously, after Tencent#795 made it async and keyed by the scope config, so main no longer type-checks and stats-scope fails. It also subtracted the shared reported-*.json, which no scope writes since Tencent#786. stats now filters with the config it resolved and subtracts that scope's own snapshots (readReportedInterventions / readReportedPromptTokens, the report's readers), so what it shows matches what pull reports. The user scope leaves a project's older sessions out, as the report does (Tencent#785); the stats-scope case that pinned "no exclusion in the user scope" now expects that.
…nst its own snapshots (#785, #786) (#791) * fix(report): each scope reports only the dashboard sessions recorded in it (#785) Every scope read one machine-wide events.jsonl and picked its sessions out by cwd prefix. The user scope excluded nothing, so a user-scope pull reported every project's sessions (and, through the shared reported snapshots, took them from the project's own report); Copilot sends no cwd, so a project never reported its Copilot sessions; and a raw cwd under a symlink or /tmp never matched the realpath'd projectRoot. The hook now stamps each event's dataHome with the data home of the scope the dispatcher resolved (the key the per-scope usage file already uses), and a report keeps only its own scope's events, comparing realpath'd keys. A project also owns its in-repo .teamai key, where hooks record until migration moves it to a partition. Events written before this carry no dataHome: a project keeps those whose realpath'd cwd is under its root, the user scope never reports them. The log stays machine-wide for the dashboard UI, stats --by-repo, session save and the contribute check. Removes the excludeProjectRoots option, which pull only ever passed as [] (the user target exists only when no project config resolved), and the projectRoot option now carried by selfConfig. The usage guide documents how to remove by hand a skill an earlier release pushed into stats/<user>.yaml from another project. * fix(report): address pre-review findings (#785) - Events record `dataHomeKey`, a hash of the realpath'd data home, instead of the path. A Copilot event persisted a workspace path through its data home (the raw root for a non-git project, the path-derived partition name otherwise), breaking the path-free Copilot contract from #666. - A data home that no longer exists (an in-repo .teamai removed after migration) keys through its parent's realpath, so it still matches the key recorded while it existed. - A non-git project's root is realpath'd before older events' cwd is matched against it, as the cwd already was. - A key that is not a string (a hand-edited log) counts as absent instead of throwing and skipping the whole report. - The legacy `dashboard-report` command's stamping is asserted. - CHANGELOG and the comment say teamai does not record Copilot's cwd, not that Copilot sends none. * docs(report): place the stats cleanup under usage reporting (#785) The manual `stats/<user>.yaml` cleanup sat under single-repo mode, but the pre-#748 leak hit every team with a git-kind repo, so it moves to "Usage reporting" and notes where an `http` team repo keeps the file. The guide also says the scope key is per event: hooks that run outside the project (a worktree removed before the session ends) report to the scope they ran in. * docs(report): name where unattributed sessions go (#785) The CHANGELOG now says a session in a directory that resolves to no project (a non-git project's subdirectory, a submodule or nested clone) is the user scope's, as for skill usage. The usage guide drops the line on http team repos: pull does not report usage to them, so no stats file there needs cleaning. * fix(report): each scope keeps its own reported dashboard snapshots (#786) The report sends per-session deltas against reported-*.json snapshots that every scope shared. A session whose events belong to two scopes (a cd into another project mid-session) was then reported by the first scope, and the second compared its own part with the first scope's totals and sent nothing. Each scope now keeps its snapshots in <dataHome>/dashboard/, and the user scope, whose data home holds the shared files, in user-reported-*.json. The first time a scope needs one it copies the shared file, so the first report after the upgrade sends nothing already reported; after that it reads only its own. The user scope moves too, unlike the ticket proposed: had it kept writing the shared file, a project seeding later would copy the user scope's part of a split session and report nothing for its own. The shared file is no longer written, except by an earlier release after a rollback, which only a scope not yet seeded reads. * fix(report): report each dashboard session once, from the scope it started in (#785, #786) A Stop carries the whole transcript's totals (prompts, tokens, interventions, request cost). Filtered per event, a session that moved into another scope mid-session was reported whole again by the scope holding the later Stop: 3 user-scope prompts then 2 in P reported 3 to the user team and 5 to P. Each session is now decided once, by its first keyed event, and reported whole by that scope. This replaces #786's "a split session reaches both teams with its part"; per-scope snapshots stay, so a session ID another scope already reported (Copilot's PID fallback) still counts as new. Unkeyed sessions from before the upgrade are decided by their first cwd. The user scope now takes those whose directory still exists and resolves to it (resolveConfigForDir, the dispatcher's rule) instead of dropping its whole backlog; no cwd, or one removed since, is still no scope's. The Copilot test also runs a payload without cwd from a hook in the project. * fix(stats): read the scope's own dashboard filter and snapshots (#785, #786) `teamai stats` (#771) still called filterEventsByScope with the old { projectRoot, excludeProjectRoots } options, synchronously, after #795 made it async and keyed by the scope config, so main no longer type-checks and stats-scope fails. It also subtracted the shared reported-*.json, which no scope writes since #786. stats now filters with the config it resolved and subtracts that scope's own snapshots (readReportedInterventions / readReportedPromptTokens, the report's readers), so what it shows matches what pull reports. The user scope leaves a project's older sessions out, as the report does (#785); the stats-scope case that pinned "no exclusion in the user scope" now expects that. * fix(stats): address CI review (#785) A session ID now names one run up to its session_end or process_exit. A PID-fallback ID (Copilot) comes back for a later run, maybe in another scope, and the log keeps the ended run below the compaction threshold, so grouping by ID alone gave the later run to the first run's scope. Each run is still decided whole by its first keyed event. Events written by main since #795 record the data home as a path (`dataHome`); the report now keys them the way the writer derives `dataHomeKey`, so pending Copilot sessions (no cwd) are not dropped. * fix(stats): address CI review (#785) A later run of a reused session ID (Copilot's PID fallback) was decided on its own but returned under the same ID, so aggregation and the per-scope snapshots merged two runs in one scope back into one session. The filter now returns a later run as `<id>@<first event timestamp>`; the first run keeps the bare ID, so existing snapshots still match. An unkeyed event's cwd under a project root counted even when the directory was gone (realpath fell back to the raw path). It now counts only while it exists, as the docs and the user-scope rule already say. * fix(stats): address CI review (#785) Run identity no longer depends on which earlier runs compaction kept: every run is `<id>@<first event timestamp>`, so a reused PID-fallback ID is a new session even when the scope's snapshot still names the run compaction dropped. Snapshot entries keyed by the bare ID (written by earlier builds) are adopted by the first run of that ID in the log, so the upgrade re-sends nothing; the next snapshot holds only run IDs. An unkeyed event's cwd is now owned by the scope resolveConfigForDir resolves it to, for projects as for the user scope, so a nested clone under a project is no longer reported by both. The lexical root matcher and its string-level tests go; the cases move to real repositories. * fix(stats): address CI review (#785) adoptBareKeys() read a legacy bare `pid-N` snapshot entry as the first run's, but only in memory: the success writes merge into the file, and with nothing new to report nothing was written, so the bare entry stayed. Once compaction dropped that run, the next run reusing `pid-N` read it and was suppressed. The report now writes each snapshot as soon as a bare entry is retired, under the run ID only, even when there is no delta. * fix(stats): address CI review (#785) A bare snapshot entry is given only to a run an earlier release recorded (its first event has no dataHomeKey). Only earlier releases wrote bare entries, and a seeded one may be another scope's run under a reused PID-fallback ID, so a run this release recorded takes none. A marker of the seed time would miss the common case: a scope seeds at its first report, usually the pull its first session's SessionStart triggers. A second end of a run with nothing recorded since the first (the dashboard monitor's process_exit after SessionEnd) joins the run it closed instead of opening a terminal-only run counted as a session. * fix(stats): address CI review (#785) A scope's first snapshot is seeded only with the shared entries of its own runs in the log, under their run IDs, and none for a run recorded with a dataHome path: that release already kept per-scope snapshots, so a shared entry under the same ID is another scope's. An unmatched entry is dropped instead of copied, so a later reuse of the ID cannot inherit it. The dashboard monitor records processExitAfter, the last event it observed, and the scope filter closes only that run. A delayed exit appended after the next run of the same ID began no longer ends it and splits it in two; an exit whose run compaction dropped is ignored. * fix(stats): address CI review (#785) An earlier release summed every run of a reused ID under its bare snapshot entry, but only the first retained run adopted it, so the next one was reported again. Each of those runs in the log but the last is now taken as reported at its own totals and the last takes the entry, in the report, in teamai stats and in the seed from the shared file. The last run is undercounted by at most the other runs' share, once. A session_start on a fallback ID from another monitorPid than its open run's begins a new run, so a run that crashed with no dashboard running no longer takes the next invocation, maybe another scope's. A tool's own ID is not split: Claude fires SessionStart again on resume, in a new process, and its Stop carries the whole transcript. * fix(stats): address CI review (#785) An end splits runs only on a fallback ID (pid-…). A tool's own session ID is one session whatever ends it records: claude --resume continues it in a new process, and its Stop carries the whole transcript, so a second run counted it again, maybe in another scope. * fix(stats): address CI review (#785) A tool's own session ID is keyed by the ID itself again, as on main, not by its first event's timestamp, so a session resumed after compaction dropped its events still reads what its scope reported. Only PID-fallback runs carry the timestamp. A bare fallback entry is the sum of the runs of its ID in the log at the earlier release's last report, and compaction keeps or drops an ID's runs together. Those runs now consume it in log order, each up to its own totals, so a later run that release never reported is sent instead of taking the whole entry. The prompt-token snapshot decides which runs it covered; interventions and daily follow it, and the first run always takes a share. * fix(stats): address CI review (#785) Seeding a scope from the shared snapshot splits the whole log into runs, lets every scope's runs of a bare ID consume its entry in log order, and keeps the shares of the scope's own runs. The shared file summed every scope's runs, so one scope consuming it alone could spend another scope's baseline and suppress its own pending run. The scope that first reports a tool's own session ID records itself in ~/.teamai/dashboard/session-owners.jsonl (the ID and its data home key, no path), and a recorded session stays that scope's wherever it is resumed, after compaction dropped its events too. A dashboard started before processExitAfter existed reads the log and appends its exit in one pass, so an unannotated exit less than one PID check after the open fallback run began belongs to the run closed before it instead of closing the next invocation. * fix(stats): address CI review (#785) A run taking its share of an earlier release's summed daily snapshot keeps its own success and correction flags: the sum's are no single run's (a successful run and an interrupted one sum to unsuccessful), so an adopted run changed sessionsSucceeded without sessionsEnded. An unannotated process_exit from a dashboard started before processExitAfter no longer ends the open fallback run when more events of that ID follow before the next start: a dead process records nothing more, so it was observed before that run and belongs to the run closed before it. This replaces the 15 s window, which a delayed callback or a skewed clock could miss. * fix(stats): address CI review (#785) session-owners.jsonl is first written from the per-scope snapshots an earlier release left: a tool's own ID in the user scope's or a partition's prompt-token snapshot is that scope's, so a session main reported in P, compacted and resumed in Q, stays P's instead of being reported again to Q. An ID the shared snapshot also holds is left out: main copied the shared file into every scope, so it names no owner, and every scope already has its baseline. The file is created exclusively, so a concurrent report in another scope reads the one written first. * fix(stats): address CI review (#785) Owner migration reconciles every per-scope baseline of an ID: a tool's own ID in any of a scope's three snapshots is the scope's that holds its greatest total (prompts, then tokens). A session main split per event holds only part of it elsewhere, and a scope may have reported past the shared total it was seeded with, so neither the first holder nor leaving shared-held IDs out was right; a session reported with no prompts, only its intervention count, is found too. Besides the user scope and the partitions, it reads a project whose data home is in its workspace that a session still in the log leads to, and each report records the IDs of its own snapshots that no owner claims yet, for such a project the log no longer leads to. * fix(stats): address CI review (#785) Owner migration assigns no owner when the greatest total ties across scopes: main copied the shared snapshot into every scope, so equal totals show only that copy, and each scope already holds the baseline. A report records an ID of its own snapshots only when they show it reported it (absent from the shared snapshot, or past its total there), so a copy no longer claims it either. A crashed fallback run a start from another process supersedes counts as the run closed before it, so a late unannotated exit of it no longer closes the new run. * test(stats): pin a pre-upgrade exit reported before the next run's first prompt (#785) The run split is recomputed from the whole log on every report, so once the next run's first prompt follows the unannotated exit, the exit is the earlier run's and the next run keeps its ID: the second pull reports only its delta, not another session. * fix(stats): give a compacted session resumed elsewhere to the project its transcript started in (#785) Once compaction dropped every event of a project whose data home is in its workspace, nothing outside it pointed to it, so a resume of its Claude session in another project reported the transcript there again. The transcript itself records where the session started: a Claude transcript keeps its first cwd when resumed from another project (the resume appends to the same file), as a Codex rollout keeps its session_meta. Hooks now record transcriptPath on UserPromptSubmit and SessionEnd as well as Stop (not SessionStart, whose path on such a resume names a file that never exists; never Copilot's). A tool's own session with no owner is the scope's that its origin resolves to, when that scope's snapshots already hold it; otherwise it is decided as before. * fix(stats): address CI review (#785) A session main split across scopes per event is credited once with every part it reported: for each scope its `dataHome` names, the shortest prefix of its events whose metrics reach its snapshot, and the owner takes the metrics of their union as reported when they exceed its own entry. Parts counted before any Stop carried the transcript's total are no longer sent again by the owner, and cumulative Stops are not credited twice. A Copilot session with an explicit ID is traced to where it started by Copilot's own session log, found by the session ID (TeamAI stores no path of it, #666): its session.start context names the directory. Compaction keeps a session whose tool process is still running, so a run that an exit from a dashboard before processExitAfter marked stopped keeps its start and its ID. * fix(stats): address CI review (#785) Owner migration takes a scope's entry as evidence only when its snapshots show it reported the ID: the shared snapshots (interventions included) hold none of it, or the scope is past their total. Main copied the shared file into every scope it ran in, so a copy, even the only one, names no owner, and the per-report recording follows the same rule. A session main split across scopes whose events are gone is credited from the parts' snapshots: a part whose daily entry shows a Stop holds the transcript's cumulative total, so the greatest counts once; a part with no Stop counted its own prompts, which add; intervention counts add, tokens take the greatest. The credit rides on the owner's line in session-owners.jsonl (numbers only) and is applied once as its baseline. * fix(stats): keep a tool's own sessions in the first snapshot, parse legacy entries (#785) Seeding a scope's snapshot from the shared one kept only the runs still in the log, so a session reported before #795 and compacted before the scope's first pull was sent again in full when resumed. Only fallback entries need that filter, against a reused PID; a tool's own session ID is one session, so its entry is copied whole, as main did. Splitting a bare entry across runs read the snapshot entry as typed, and one without `tokens` (hand-edited or truncated) threw and skipped the whole report; the prompt-token and intervention shares now parse it as the owner-migration path already does. * fix(stats): report a resumed Codex rollout after compaction dropped the earlier one (#785) A Codex build that writes a new rollout per resume restarts its transcript counters, and the session summed only the rollouts still in the log. Once compaction dropped rollout A, a resumed rollout B with smaller counters was compared against A's reported total and reported nothing until it passed it; routing the session back to the scope it started in made that loss reach the resume in another scope too. The prompt-token snapshot now keeps each rollout's reported prompts and tokens under a hash of its path (no path stored), and a rollout that is gone keeps its reported totals in the session's sum, so B is reported in full. A session's prompts also sum its rollouts' Stop counts, which restart per rollout like the tokens. An entry from before is compared as a whole once, then kept per rollout. * refactor(stats): move dashboard scope attribution and session owners out of team-push (#785) No behavior change. src/dashboard-scope.ts holds which scope reports a dashboard session (the log split into runs, each given whole to the scope it started in, and the transcript origin); src/session-owners.ts holds the machine-level owners index, its seeding from earlier snapshots, and the snapshot files it reads. team-push.ts keeps the snapshot adoption, deltas and push, and one reportedBaselines() now serves both the report and `teamai stats`, which repeated the adoption sequence. * test(stats): real CLI resume of a compacted session from a workspace-data project (#785) A non-git project W keeps its data home in its workspace. W reports a Claude session; with no owners index and every W event compacted, the session is resumed in git project Q through the real hook dispatcher, appending to W's transcript. Q reports only its own session and W the resumed turn; on a build without the transcript origin, Q reports both. The fixture gains a second project and hooks sent as the installed ones send them. * fix(stats): credit a split session's Stop-derived interventions once (#785) Interruptions and tool rejections come from Stops, which carry the transcript's cumulative counts, so a compacted split session's credit takes the greatest part, as it does for tokens; summing them made the next cumulative Stop report nothing. Corrections are counted per prompt in each part's own events, so they still add. * fix(stats): place a compacted split session's parts by its transcript (#785) A split session's credit added a part with no Stop to the greatest cumulative Stop, which already counts that part when it came before the Stop: after 3 prompts in P and a cumulative Stop of 5 in Q it credited 8, and the next Stop of 6 reported nothing. The credit now keeps each part (scope key, prompts, whether it ended in a Stop; numbers only), and the owner places them by the session's transcript, which keeps every prompt in order with the directory it was typed in: the Stop covers the first prompts, and only the part's prompts after those add. With no transcript to place them, they all add, as before. The Stop scan's human-turn test is now isHumanPromptEntry, shared by both, so the two count prompts alike. * fix(stats): keep a dropped Codex rollout's totals for daily and interventions, and migrate whole entries (#785) An entry from before rollouts were kept is one total. An earlier release rewrote every session in the log on each report, so it covers the rollouts begun by the time its file was last written, read before this report writes it: those still in the log consume it in order, what is left is the dropped rollouts', kept as one prior rollout, and a rollout begun later is new. Rollout B after a compacted A is no longer compared against A's total and lost. A rollout also keeps its Stop's interruptions and rejections, and its dropped totals now reach the intervention and daily sums too, not just prompts and tokens: daily prompt turns and intervention counts of a resumed rollout were compared against the dropped one's. * fix(stats): keep every metric of a dropped Codex rollout, with or without tokens (#785) A Codex session is now kept per rollout whenever its Stops name a rollout, not only once a Stop carries a token record, so a tokenless resumed rollout is not compared against the dropped one's totals. Each rollout also keeps its corrections (a correction goes to the rollout of its prompt), its active time (each gap to the rollout of the event it ends at) and its request costs, and a dropped rollout adds them to the intervention and daily sums, with its cache tokens from its tokens. The prompt-token snapshot, which holds the rollouts, is written with any delta, so a rollout whose rejections alone moved keeps its new totals. * fix(stats): sum a Codex session's rollout costs and keep a dropped rollout's failure (#785) The daily snapshot took the request costs of the latest rollout only, so with rollout A still in the log a rollout B was compared against A's costs and clamped; a Codex session's daily costs now sum its rollouts. Each rollout also records whether it failed (an error, an interruption or a correction). A dropped rollout that failed keeps the session unsuccessful, and one with a correction keeps it corrected, so a clean later rollout does not turn it into a success. * fix(stats): keep modern Codex rollouts, and their submit-counted prompts, per rollout (#785) A Codex session whose tokens come from the thread-level counter (tokenScope session) was not split into rollouts, so its prompts, interventions, active time, costs and failure were compared against a dropped rollout's. It is now kept per rollout like the others; the counter already spans the rollouts, so no rollout holds tokens of its own and the session total stays that counter's. A Codex Stop may count no prompts, so a rollout's prompts are its Stop's count or else its own submits: a dropped rollout's submit-counted prompts are no longer lost. * fix(stats): no legacy tokens on a spanning Codex counter; teamai stats writes no seed (#785) A whole entry an earlier release left became a prior rollout carrying its tokens, which were then added to a thread-level counter that already holds them: rollout B's counter at 530 after A's 500 re-sent 500. A session whose counter spans its rollouts now takes no tokens from a dropped or prior rollout. `teamai stats` only reads, but seeding a scope's first snapshot wrote it with the current time, which a later report reads as the time an entry from before covers, taking a rollout begun earlier as reported. A read that does not persist now writes no seed, and a written seed keeps the shared file's time. * fix(stats): read a legacy daily entry's session cost fields as its day's costs (#785) parseDailySnapshot() dropped the top-level pricedRequests, costMicros, cache tokens and priceVersion a daily entry from before per-day costs held, so an entry from before rollouts were kept lost its cost in the prior rollout, and a later rollout's cost was compared against it and omitted. They are now read as the session day's request costs, as computeDailyStatsDelta already reads them. * fix(stats): keep every Codex variant per rollout, and an older Stop's request cost (#785) Rollout tracking recognized only `codex`, not `codex-internal` or `tcodex`, which write the same rollouts; it now uses isCodexTool(). A rollout's cost was read from requestDaily only, so an older Stop's requestMetrics left the rollout without cost, and the daily snapshot, which sums rollouts, omitted it; it is now that Stop's day's cost, as outside rollouts. * fix(stats): keep a Codex rollout's latest Stop by timestamp (#785) A rollout's prompts, interventions and request costs took the last Stop appended, though background Stop handlers may append an older scan after a newer one, which then replaced the newer totals. They now keep the latest Stop by its timestamp, as the rollout's tokens already do. * fix(stats): an entry from before covers a running Codex rollout only as far as it had got (#785) Migrating a whole entry from before rollouts were kept consumed it with each covered rollout's current totals, so a rollout begun before the entry was written but grown since had its later prompts taken as reported: an entry of 6 (A's 5, B's 1) with B now at 3 reported nothing. It now consumes it with each rollout's totals as of the entry's write, the metrics of the events up to then; what a rollout has done since is new. * fix(stats): credit a split session counter by counter; read an old entry's cutoff before its push (#785) Both credit paths applied only when the parts' prompts exceeded the owner's, so a part that reported more active time, tokens or costs with no more prompts was sent again by the owner. The owner's entry is now raised counter by counter to at least the credit. An earlier release wrote its snapshot after the push, so events that arrived during the push predate the snapshot's time without being in it. The team stats file in the scope's reports checkout was written after that report read the log and before the push; the earlier of the two times is now the cutoff an entry from before covers.
What's the problem
teamai statsshowed more sessions, conversation turns and tokens than the team actually holds for that member. Two separate over-counts, both in the dashboard section ofshowStats:Every reported session was counted twice. Reported sessions stay in
~/.teamai/dashboard/events.jsonluntil compaction, andshowStatsadded the whole machine's local aggregate on top of the scope's already-reported totals fromstats/<user>.yaml. So a session counted once by the team total was counted a second time locally — permanently, for as long as the event survived compaction.Another project's sessions were added to this scope's totals. The event log is one machine-wide file whose events carry a
cwd, and nothing filtered it. Runningteamai statsinside project A reported project B's sessions as A's.The write side was already correct:
teamai pull→reportUsageToTeamfilters the event log withfilterEventsByScopeand reports only the delta against the per-sessionreported-*.jsonsnapshots. The display side used neither, so the local figure could never agree with the team's.What's the fix
Make the display side use the same rules the report side already uses:
filterEventsByScopebefore aggregation, using the same optionspullderives: a project scope keeps only sessions under itsprojectRoot; the user scope excludes project roots. Imported fromteam-push.tswith a dynamicimport()to avoid a static cycle (team-push.tsalready importsstats.tsforaggregateUsage).unreportedDashboardStats()feeds the filtered metrics through the already-exportedcomputeInterventionDeltaandcomputePromptTokenDeltaagainst the samereported-interventions.json/reported-prompt-tokens.jsonsnapshots the report path advances. The result is exactly whatpullwould push right now, soshowStatsdisplays reported + unreported instead of reported + everything.--by-repoand--by-timeuse the same filtered log, so the breakdowns stay consistent with the headline numbers instead of contradicting them.mergeDashboardAndReportedis unchanged in behaviour; its doc comment now states the precondition its caller must meet (thatlocalis the unreported delta, not the full aggregate), since that is what was violated.Fixes the second half of #768. The first half (the legacy
teamai dashboard-reportsubcommand recording events with no config check) is left for a separate change.Test plan
New
src/__tests__/stats-scope.test.ts(10 tests), seeding a real git workspace sodetectProjectConfigresolves a project scope, a realevents.jsonl, the team'sstats/tester.yaml, and both reported snapshots:Sessions: 2(reported 1 + local 1)1Sessions: 3(reported 1 + local 2)2Sessions: 2(proj-a 1 + proj-b 1)1Tokens: 300(reported 150 + local 150)150By Repo:reported === null→No usage data yet.Plus the project/user scope split: a user-scope run from a plain directory excludes nothing (no project resolves there, exactly as
pullsees it), and the same machine run from inside the project keeps only that project's sessions.Verified as follows:
npx tsc --noEmit→ exit 0.usage-tracking.test.tsseeds a skill namedgstack:tdd, and:is not legal in a Win32 filename). CleanHEAD: 105 failed / 4240 passed. With this change: 104 failed / 4241 passed. No new failures introduced.src/__tests__/stats-scope.test.ts→ 8 tests pass. Covers sessions, conversation turns and token totals, the project/user scope split, and the agreement between the headline and the--by-repobreakdown.npx tsc --noEmit→ exit 0.npm run build→ success.usage-tracking.test.tsseeds a skill namedgstack:tdd, and:is not legal in a Win32 filename). CleanHEAD: 105 failed / 4240 passed. With this change: 103 failed / 4242 passed. No new failures introduced.End-to-end with the real CLI
npm run build, thenteamai statsandteamai stats --by-repoin an isolatedHOMEholding a user-scope config, a teamstats/jeff.yamlwith 3 reported sessions / 300 turns / 1500 tokens, bothreported-*.jsonsnapshots covering one of the two sessions inevents.jsonl, and one unreported session:4= the 3 sessions the team already holds + the 1 this machine has not pushed;301= 300 + 1. Before the fix the same input reported the local aggregate as well, counting every retained session a second time.The breakdown answers a different question — what this machine's retained event log holds, per repo — so it shows the 2 sessions on disk rather than 4. Both read the same scope, which is what keeps another project out of each. The two are not expected to match number for number: the reported totals include other machines and sessions compaction has already dropped. The heading names its source rather than inviting the reader to add them up.
Second run, same fixture with the team stats file removed, so
loadReportedStats()returns null:Nothing is subtracted when there is no team total to reconcile against — otherwise the local snapshot, which records what this machine pushed rather than what the team holds, would hide the sessions entirely.
Third run, the scope-filtering half: the same fixture plus a session whose
cwdbelongs to a second project, run from inside a project workspace sodetectProjectConfig()resolves a project scope:4= the 3 reported sessions +sess-2, this project's unreported session. The second project's session is in neither the headline nor the breakdown — that is the exclusion the change is for. The same shape is a unit test, and removing the scope filter turns four tests red at once (the project-scope one reporting5instead of4).Review follow-up
Project root resolved independently —
detectProjectConfig()is called on its own, the same callpullmakes atpull.ts:1899, instead of readingprojectRootoff the resolved scope config. A user-scope config carries noprojectRoot; the field is attached only when a project config is detected, so the previous expression could never populate the user scope's exclusion list.On reachability: I probed whether a user-scope run can ever coincide with a detected project, and it cannot.
resolveConfigForDir()callsdetectProjectConfig()first and returns the project config whenever one resolves, soscope !== 'project'implies detection failed, which implies noprojectRootto exclude. The old expression therefore could not mis-attribute a session — but it was fragile for exactly the reason given, and the code now names its source of truth. Two tests pin both branches: a plain directory excludes nothing, and inside the project only that project's sessions are kept.Breakdowns: same scope, their own question — the second round of review was right that filtering the breakdowns to unreported sessions was wrong. The headline adds this machine's unreported sessions to totals that already include other machines and sessions compaction has dropped, so it can never equal a per-repo or per-hour view of the local log; filtering made the breakdowns show neither a total nor a delta, and a fully reported project disappeared from
--by-repoentirely. Reverted to the full scoped log, with the heading now naming its source (By Repo (local event log)) so the two are not read as one sum. What both do share is the scope filter, and that is now genuinely pinned: dropping it turns the cross-project case red — the earlier assertion missed it because it read only the first matching row.Token totals asserted — the regression tests check
Tokens (total)/Input/Output, not only sessions and turns.Notes for reviewers
scopeFilterisundefined, andfilterEventsByScopereturns the log unfiltered — the pre-scope behaviour is preserved there.filterEventsByScopeand both delta helpers are already exported onmain, and the snapshot readers were already there.